winbrew_app\operations\doctor\scan/
orphan.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::models::domains::installed::InstalledPackage;
5use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
6
7use super::{OrphanInstallScan, ScanResult, sort_diagnoses, sort_recovery_findings};
8
9/// Scan the package root for directories that do not correspond to packages in the database.
10///
11/// Orphaned directories are reported as warnings because they indicate stale
12/// filesystem state rather than a broken package record. If the root directory
13/// itself cannot be read, the function returns a single error diagnostic so the
14/// caller can surface the database problem directly.
15pub(super) fn scan_orphaned_install_dirs(
16    packages_root: &Path,
17    packages: &[InstalledPackage],
18) -> OrphanInstallScan {
19    let mut known_packages = HashSet::with_capacity(packages.len());
20    known_packages.extend(packages.iter().map(|pkg| pkg.name.as_str()));
21
22    let entries = match std::fs::read_dir(packages_root) {
23        Ok(entries) => entries,
24        Err(err) => {
25            let mut result = ScanResult::default();
26            result.push(
27                DiagnosisResult {
28                    error_code: "packages_root_unreadable".to_string(),
29                    description: format!(
30                        "packages root: unreadable packages directory ({}) - {err}",
31                        packages_root.to_string_lossy()
32                    ),
33                    severity: DiagnosisSeverity::Error,
34                },
35                None,
36            );
37            return result;
38        }
39    };
40
41    let mut result = ScanResult::default();
42
43    for entry in entries.flatten() {
44        let file_type = match entry.file_type() {
45            Ok(file_type) => file_type,
46            Err(_) => continue,
47        };
48
49        if !file_type.is_dir() {
50            continue;
51        }
52
53        let path = entry.path();
54
55        let package_name = match path.file_name().and_then(|value| value.to_str()) {
56            Some(package_name) => package_name,
57            None => continue,
58        };
59
60        if known_packages.contains(package_name) {
61            continue;
62        }
63
64        result.push_orphan(package_name, &path);
65    }
66
67    sort_diagnoses(&mut result.diagnostics);
68    sort_recovery_findings(&mut result.recovery_findings);
69
70    result
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::models::domains::install::InstallerType;
77    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
78    use crate::models::domains::reporting::{RecoveryActionGroup, RecoveryIssueKind};
79    use std::fs;
80    use tempfile::tempdir;
81
82    fn sample_package(name: &str, install_dir: &std::path::Path) -> InstalledPackage {
83        InstalledPackage {
84            name: name.to_string(),
85            version: "1.0.0".to_string(),
86            kind: InstallerType::Portable,
87            deployment_kind: InstallerType::Portable.deployment_kind(),
88            engine_kind: InstallerType::Portable.into(),
89            engine_metadata: None,
90            install_dir: install_dir.to_string_lossy().into_owned(),
91            dependencies: Vec::new(),
92            status: PackageStatus::Ok,
93            installed_at: "2026-04-05T00:00:00Z".to_string(),
94        }
95    }
96
97    #[test]
98    fn scan_orphaned_install_dirs_skips_known_packages() {
99        let temp_dir = tempdir().expect("temp dir should be created");
100        let packages_root = temp_dir.path().join("packages");
101        fs::create_dir_all(&packages_root).expect("packages root should be created");
102
103        let known_package_dir = packages_root.join("Contoso.Known");
104        fs::create_dir_all(&known_package_dir).expect("known package directory should be created");
105
106        let orphan_dir = packages_root.join("Contoso.Orphan");
107        fs::create_dir_all(&orphan_dir).expect("orphan directory should be created");
108
109        let known_package = sample_package("Contoso.Known", &known_package_dir);
110
111        let scan = scan_orphaned_install_dirs(&packages_root, &[known_package]);
112
113        assert_eq!(scan.diagnostics.len(), 1);
114        assert_eq!(scan.diagnostics[0].error_code, "orphan_install_directory");
115        assert_eq!(scan.recovery_findings.len(), 1);
116        assert_eq!(
117            scan.recovery_findings[0].issue_kind,
118            RecoveryIssueKind::IncompleteInstall
119        );
120        assert_eq!(
121            scan.recovery_findings[0].action_group,
122            Some(RecoveryActionGroup::OrphanCleanup)
123        );
124        assert_eq!(
125            scan.recovery_findings[0].target_path.as_deref(),
126            Some(orphan_dir.to_string_lossy().as_ref())
127        );
128    }
129
130    #[test]
131    fn scan_orphaned_install_dirs_ignores_files_in_packages_root() {
132        let temp_dir = tempdir().expect("temp dir should be created");
133        let packages_root = temp_dir.path().join("packages");
134        fs::create_dir_all(&packages_root).expect("packages root should be created");
135
136        let file_path = packages_root.join("README.txt");
137        fs::write(&file_path, b"not a directory").expect("file should be created");
138
139        let scan = scan_orphaned_install_dirs(&packages_root, &[]);
140
141        assert!(scan.diagnostics.is_empty());
142        assert!(scan.recovery_findings.is_empty());
143    }
144
145    #[test]
146    fn scan_orphaned_install_dirs_reports_unreadable_packages_root() {
147        let temp_dir = tempdir().expect("temp dir should be created");
148        let packages_root = temp_dir.path().join("packages");
149        fs::write(&packages_root, b"not a directory")
150            .expect("packages root file should be created");
151
152        let scan = scan_orphaned_install_dirs(&packages_root, &[]);
153
154        assert_eq!(scan.diagnostics.len(), 1);
155        assert_eq!(scan.diagnostics[0].error_code, "packages_root_unreadable");
156        assert_eq!(
157            scan.diagnostics[0].severity,
158            crate::models::domains::reporting::DiagnosisSeverity::Error
159        );
160        assert!(scan.recovery_findings.is_empty());
161    }
162
163    #[test]
164    fn scan_orphaned_install_dirs_returns_empty_for_empty_root() {
165        let temp_dir = tempdir().expect("temp dir should be created");
166        let packages_root = temp_dir.path().join("packages");
167        fs::create_dir_all(&packages_root).expect("packages root should be created");
168
169        let scan = scan_orphaned_install_dirs(&packages_root, &[]);
170
171        assert!(scan.diagnostics.is_empty());
172        assert!(scan.recovery_findings.is_empty());
173    }
174
175    #[test]
176    fn scan_orphaned_install_dirs_detects_directories_without_packages() {
177        let temp_dir = tempdir().expect("temp dir should be created");
178        let packages_root = temp_dir.path().join("packages");
179        fs::create_dir_all(&packages_root).expect("packages root should be created");
180
181        let orphan_dir = packages_root.join("Contoso.Orphan");
182        fs::create_dir_all(&orphan_dir).expect("orphan directory should be created");
183
184        let known_package = sample_package("Contoso.Known", &packages_root.join("Contoso.Known"));
185
186        let scan = scan_orphaned_install_dirs(&packages_root, &[known_package]);
187
188        assert_eq!(scan.diagnostics.len(), 1);
189        assert_eq!(scan.diagnostics[0].error_code, "orphan_install_directory");
190        assert_eq!(
191            scan.diagnostics[0].severity,
192            crate::models::domains::reporting::DiagnosisSeverity::Warning
193        );
194        assert_eq!(scan.recovery_findings.len(), 1);
195        assert_eq!(
196            scan.recovery_findings[0].issue_kind,
197            RecoveryIssueKind::IncompleteInstall
198        );
199        assert_eq!(
200            scan.recovery_findings[0].action_group,
201            Some(RecoveryActionGroup::OrphanCleanup)
202        );
203        assert_eq!(
204            scan.recovery_findings[0].target_path.as_deref(),
205            Some(orphan_dir.to_string_lossy().as_ref())
206        );
207    }
208}